Оценка результатов А/В - теста¶

Исходные данные: датасет с действиями пользователей, техническое задание и несколько вспомогательных датасетов

Цель: провести оценку результатов A/B-теста

Основные задачи:

1) Оценить корректность проведения теста:

  • проверить пересечение тестовой аудитории с конкурирующим тестом,

  • проверить совпадение теста и маркетинговых событий, другие проблемы временных границ теста.

2) Проанализируровать результаты теста.

Техническое задание (ТЗ):

  • Название теста: recommender_system_test;
  • группы: А — контрольная, B — новая платёжная воронка;
  • дата запуска: 2020-12-07;
  • дата остановки набора новых пользователей: 2020-12-21;
  • дата остановки: 2021-01-04;
  • аудитория: 15% новых пользователей из региона EU;
  • назначение теста: тестирование изменений, связанных с внедрением улучшенной рекомендательной системы;
  • ожидаемое количество участников теста: 6000.
  • ожидаемый эффект: за 14 дней с момента регистрации пользователи покажут улучшение каждой метрики не менее, чем на 10%:
    • конверсии в просмотр карточек товаров — событие product_page,
    • просмотры корзины — product_cart,
    • покупки — purchase.

Загрузка и подготовка данных к анализу¶

In [1]:
# импорт необходимых библиотек
import pandas as pd
import plotly.express as px
import matplotlib.pyplot as plt
import seaborn as sns
import datetime
from plotly import graph_objects as go
from scipy import stats as st
import math as mth
import warnings
warnings.filterwarnings('ignore')
In [2]:
# чтение данных из файла
events, marketing_events, new_users, participants = (
    pd.read_csv('/Users/olgakozlova/Desktop/datasets/final_ab_events.csv'), 
    pd.read_csv('/Users/olgakozlova/Desktop/datasets/ab_project_marketing_events.csv'),
    pd.read_csv('/Users/olgakozlova/Desktop/datasets/final_ab_new_users.csv'),
    pd.read_csv('/Users/olgakozlova/Desktop/datasets/final_ab_participants.csv')
    )

display(events.head())
display(marketing_events.head())
display(new_users.head())
participants.head()
user_id event_dt event_name details
0 E1BDDCE0DAFA2679 2020-12-07 20:22:03 purchase 99.99
1 7B6452F081F49504 2020-12-07 09:22:53 purchase 9.99
2 9CD9F34546DF254C 2020-12-07 12:59:29 purchase 4.99
3 96F27A054B191457 2020-12-07 04:02:40 purchase 4.99
4 1FD7660FDF94CA1F 2020-12-07 10:15:09 purchase 4.99
name regions start_dt finish_dt
0 Christmas&New Year Promo EU, N.America 2020-12-25 2021-01-03
1 St. Valentine's Day Giveaway EU, CIS, APAC, N.America 2020-02-14 2020-02-16
2 St. Patric's Day Promo EU, N.America 2020-03-17 2020-03-19
3 Easter Promo EU, CIS, APAC, N.America 2020-04-12 2020-04-19
4 4th of July Promo N.America 2020-07-04 2020-07-11
user_id first_date region device
0 D72A72121175D8BE 2020-12-07 EU PC
1 F1C668619DFE6E65 2020-12-07 N.America Android
2 2E1BF1D4C37EA01F 2020-12-07 EU PC
3 50734A22C0C63768 2020-12-07 EU iPhone
4 E1BDDCE0DAFA2679 2020-12-07 N.America iPhone
Out[2]:
user_id group ab_test
0 D1ABA3E2887B6A73 A recommender_system_test
1 A7A3664BD6242119 A recommender_system_test
2 DABC14FDDFADD29E A recommender_system_test
3 04988C5DF189632E A recommender_system_test
4 482F14783456D21B B recommender_system_test
In [3]:
# функция для вывода: инфо, количество пропущенных значений, количество полных дубликатов
def df_overview(df):
    print('Общая информация о данных:\n')
    df.info()
    print('\nКоличество пропусков:\n')
    display(df.isna().sum())
    print('\nКоличество полных дубликатов:', df.duplicated().sum())

Действия новых пользователей с 07.12.2020 по 04.01.2021 год

Согласно описанию к данным:

  • user_id — идентификатор пользователя;
  • event_dt — дата и время покупки;
  • event_name — тип события;
  • details — дополнительные данные о событии. Например, для покупок, purchase, в этом поле хранится стоимость покупки в долларах.
In [4]:
# вызов функции df_overview
df_overview(events)
Общая информация о данных:

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 440317 entries, 0 to 440316
Data columns (total 4 columns):
 #   Column      Non-Null Count   Dtype  
---  ------      --------------   -----  
 0   user_id     440317 non-null  object 
 1   event_dt    440317 non-null  object 
 2   event_name  440317 non-null  object 
 3   details     62740 non-null   float64
dtypes: float64(1), object(3)
memory usage: 13.4+ MB

Количество пропусков:

user_id            0
event_dt           0
event_name         0
details       377577
dtype: int64
Количество полных дубликатов: 0

Тип данных столбца 'event_dt' (дата и время покупки) преобразуем из object в datetime:

In [5]:
# преобразование данных о времени
events['event_dt'] = pd.to_datetime(events['event_dt'], format='%Y-%m-%d %H:%M:%S')
In [6]:
# проверка типов данных после преобразования строки в дату и время
events.dtypes
Out[6]:
user_id               object
event_dt      datetime64[ns]
event_name            object
details              float64
dtype: object

Таблица events содержит данные о действия новых пользователей в период с 07 декабря 2020 года по 4 января 2021 года.

Таблица состоит из 4-х столбцов и 440317 строк. Для столбца 'event_dt' тип данных преобразован в datetime, для остальных значений столбца типы данных соответствуют заявленным.

Полные дубликаты в данных не обнаружены.

В столбце 'details' обнаружено 377 577 пропущенных значений. Однако, согласно документации, в этом столбце хранятся дополнительные данные о событии (например, стоимость покупки), поэтому принято решение оставить пропуски без изменения.

Календарь маркетинговых событий на 2020 год

Согласно описанию к данным:

  • name — название маркетингового события;

  • regions — регионы, в которых будет проводиться рекламная кампания;

  • start_dt — дата начала кампании;

  • finish_dt — дата завершения кампании.

In [7]:
# вызов функции df_overview
df_overview(marketing_events)
Общая информация о данных:

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 14 entries, 0 to 13
Data columns (total 4 columns):
 #   Column     Non-Null Count  Dtype 
---  ------     --------------  ----- 
 0   name       14 non-null     object
 1   regions    14 non-null     object
 2   start_dt   14 non-null     object
 3   finish_dt  14 non-null     object
dtypes: object(4)
memory usage: 576.0+ bytes

Количество пропусков:

name         0
regions      0
start_dt     0
finish_dt    0
dtype: int64
Количество полных дубликатов: 0

Тип данных столбцов 'start_dt' 'finish_dt' (дата начала и завершения кампании) необходимо преобразовать из object в datetime:

In [8]:
# преобразование данных о времени
marketing_events['start_dt'] = pd.to_datetime(marketing_events['start_dt'], format='%Y-%m-%d')
marketing_events['finish_dt'] = pd.to_datetime(marketing_events['finish_dt'], format='%Y-%m-%d')
In [9]:
# проверка типов данных после преобразования строки в дату
marketing_events.dtypes
Out[9]:
name                 object
regions              object
start_dt     datetime64[ns]
finish_dt    datetime64[ns]
dtype: object

Таблица marketing_events содержит данные о маркетинговых событиях 2020 года.

Таблица состоит из 4-х столбцов и 14 строк. Для столбцов 'start_dt' и 'finish_dt' тип данных изменен на datetime.

Пропущенные значения и полные дубликаты в данных не обнаружены.

Пользователи, зарегистрировавшиеся с 07 по 21 декабря 2020 года

Согласно документации к данным:

  • user_id — идентификатор пользователя;

  • first_date — дата регистрации;

  • region — регион пользователя;

  • device — устройство, с которого происходила регистрация.

In [10]:
# вызов функции df_overview
df_overview(new_users)
Общая информация о данных:

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 61733 entries, 0 to 61732
Data columns (total 4 columns):
 #   Column      Non-Null Count  Dtype 
---  ------      --------------  ----- 
 0   user_id     61733 non-null  object
 1   first_date  61733 non-null  object
 2   region      61733 non-null  object
 3   device      61733 non-null  object
dtypes: object(4)
memory usage: 1.9+ MB

Количество пропусков:

user_id       0
first_date    0
region        0
device        0
dtype: int64
Количество полных дубликатов: 0

Тип данных столбца 'first_date' (дата регистрации) преобразуем из object в datetime:

In [11]:
# преобразование данных о времени
new_users['first_date'] = pd.to_datetime(new_users['first_date'], format='%Y-%m-%d')

# добавление столбца даты регистрации для получения даты без времени 00:00:00
#new_users['registration_date'] = new_users['first_date'].dt.date
In [12]:
# проверка типов данных после преобразования строки в дату
new_users.dtypes
Out[12]:
user_id               object
first_date    datetime64[ns]
region                object
device                object
dtype: object

Таблица new_users содержит данные зарегистрировавшихся пользователях с 7 по 21 декабря 2020 года (устройство, регион, id, дата регистрации).

Таблица состоит из 4-х столбцов и 61 733 строк. У столбца 'first_date' тип данных изменен на datetime.

Пропущенные значения и полные дубликаты в данных не обнаружены.

Таблица участников теста

Согласно описанию к данным:

  • user_id — идентификатор пользователя;

  • ab_test — название теста;

  • group — группа пользователя.

In [13]:
# вызов функции df_overview
df_overview(participants)
Общая информация о данных:

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 18268 entries, 0 to 18267
Data columns (total 3 columns):
 #   Column   Non-Null Count  Dtype 
---  ------   --------------  ----- 
 0   user_id  18268 non-null  object
 1   group    18268 non-null  object
 2   ab_test  18268 non-null  object
dtypes: object(3)
memory usage: 428.3+ KB

Количество пропусков:

user_id    0
group      0
ab_test    0
dtype: int64
Количество полных дубликатов: 0

Промежуточные выводы¶

  • Загруженные данные содержат информацию о действиях пользователей, о маркетинговых событиях 2020 года, о проведенных тестах;

  • В столбце 'details' таблицы 'events' обнаружены пропуски в количестве: 377 577. Принято решение оставить пропущенные значения без изменения, так как согласно документации, в этом столбце хранятся дополнительные данные о событии (например, стоимость покупки);

  • В колонках с датой и временем тип данных object преобразован в datetime;

  • Полные дубликаты в данных не обнаружены.

Оценка корректности проведения теста¶

Для оценки корректности проведения теста необходимо удостовериться, что данные соответствуют требованиям ТЗ. А также в целом проверить корректность всех пунктов технического задания. Рассмотрим подробнее каждый датасет.

Пользователи, зарегистрировавшиеся с 7 по 21 декабря 2020 года

In [14]:
new_users
Out[14]:
user_id first_date region device
0 D72A72121175D8BE 2020-12-07 EU PC
1 F1C668619DFE6E65 2020-12-07 N.America Android
2 2E1BF1D4C37EA01F 2020-12-07 EU PC
3 50734A22C0C63768 2020-12-07 EU iPhone
4 E1BDDCE0DAFA2679 2020-12-07 N.America iPhone
... ... ... ... ...
61728 1DB53B933257165D 2020-12-20 EU Android
61729 538643EB4527ED03 2020-12-20 EU Mac
61730 7ADEE837D5D8CBBD 2020-12-20 EU PC
61731 1C7D23927835213F 2020-12-20 EU iPhone
61732 8F04273BB2860229 2020-12-20 EU Android

61733 rows × 4 columns

In [15]:
new_users['user_id'].nunique()
Out[15]:
61733

Все пользователи уникальны. Визуалзируем распределение пользователей по регионам:

In [16]:
# регионы и число пользователей
new_users['region'].value_counts()
Out[16]:
EU           46270
N.America     9155
CIS           3155
APAC          3153
Name: region, dtype: int64
In [17]:
sns.set_theme(style="whitegrid")
plt.figure(figsize=(15,5))
sns.histplot(data=new_users, x='region',)
plt.title('Распределение пользователей по регионам', fontsize=15)
plt.xlabel('Регионы')
plt.ylabel('Число пользователей')
plt.show()

Среди зарегистрировавшихся пользователей присутствуют жители Европейского союза, Северной Америки, Азиатско-Тихоокеанского региона и СНГ. Согласно ТЗ, нас интересуют только пользователи Европейского союза:

In [18]:
# фильтрация пользователей по региону EU
new_users = new_users.query('region == "EU"')

Посмотрим, как пользователи распределены по дате регистрации:

In [19]:
# столбчатая диаграмма Распределение регистраций пользователей во времени
sns.set_theme(style="whitegrid")
new_users.groupby('first_date').agg({'user_id': 'count'}).plot(kind = 'bar', figsize = (15, 5))
plt.title('Распределение регистраций пользователей во времени', fontsize=15)
plt.xlabel('Дата')
plt.ylabel('Количество пользователей')
plt.xticks(rotation = 30)
plt.show()

Дата остановки набора новых пользователей не соответствует ТЗ, вместо 2020-12-21 на графике можно наблюдать дату 2020-12-23. С датой начала набора все в порядке.

Отберем только тех пользователей, которые подходят под условия задания:

In [20]:
# фильтрация пользоватей по дате окончания набора
new_users = new_users.query('first_date <= "2020-12-21"')
new_users.shape
Out[20]:
(42340, 4)
In [21]:
# столбчатая диаграмма Распределение регистраций пользователей во времени
sns.set_theme(style="whitegrid")
new_users.groupby('first_date').agg({'user_id': 'count'}).plot(kind = 'bar', figsize = (15, 5))
plt.title('Распределение регистраций пользователей во времени', fontsize=15)
plt.xlabel('Дата')
plt.ylabel('Количество пользователей')
plt.xticks(rotation = 30)
plt.show()

Осталось 42 340 пользователей из региона EU, которые зарегистрировались в период с 2020-12-07 по 2020-12-21. Посмотрим, какие устройства пользователи использовали при регистрации:

In [22]:
# устройства, с которых регистрировались пользователи
sns.set_theme(style="whitegrid")
plt.figure(figsize=(15,5))
sns.histplot(data=new_users, x='device',)
plt.title('Устройства, с которых проходила регистрация', fontsize=15)
plt.xlabel('Устройства')
plt.ylabel('Число пользователей')
plt.show()
In [23]:
# девайсы в % от общего
device = new_users['device'].value_counts().reset_index()
device.columns = ['device', 'count']
device['% of total']= round(device['count'] / device['count'].sum() * 100, 1)
device
Out[23]:
device count % of total
0 Android 18828 44.5
1 PC 10760 25.4
2 iPhone 8591 20.3
3 Mac 4161 9.8

~ 45 % пользователей из EU зарегистрировалась через ОС Android, треть - пользователи Apple (iPhone - ~20 %, Mac - ~10 %). Четверть пользователей предпочитают PC.

Календарь маркетинговых событий

Посмотрим еще раз на календарь маркетинговых событий. Проверим, не совпадает ли время проведения теста с маркетинговыми активностями.

In [24]:
marketing_events
Out[24]:
name regions start_dt finish_dt
0 Christmas&New Year Promo EU, N.America 2020-12-25 2021-01-03
1 St. Valentine's Day Giveaway EU, CIS, APAC, N.America 2020-02-14 2020-02-16
2 St. Patric's Day Promo EU, N.America 2020-03-17 2020-03-19
3 Easter Promo EU, CIS, APAC, N.America 2020-04-12 2020-04-19
4 4th of July Promo N.America 2020-07-04 2020-07-11
5 Black Friday Ads Campaign EU, CIS, APAC, N.America 2020-11-26 2020-12-01
6 Chinese New Year Promo APAC 2020-01-25 2020-02-07
7 Labor day (May 1st) Ads Campaign EU, CIS, APAC 2020-05-01 2020-05-03
8 International Women's Day Promo EU, CIS, APAC 2020-03-08 2020-03-10
9 Victory Day CIS (May 9th) Event CIS 2020-05-09 2020-05-11
10 CIS New Year Gift Lottery CIS 2020-12-30 2021-01-07
11 Dragon Boat Festival Giveaway APAC 2020-06-25 2020-07-01
12 Single's Day Gift Promo APAC 2020-11-11 2020-11-12
13 Chinese Moon Festival APAC 2020-10-01 2020-10-07

Для наглядности построим по даным таблицы диаграмму Ганта:

In [25]:
# диаграмма Ганта календарь событий
fig = px.timeline(marketing_events, x_start='start_dt', x_end='finish_dt', y='name', title = 'Каледарь маркетинговых событий')
fig.update_yaxes(autorange='reversed')
fig.show()

Проведение теста приходится на 07.12.2020 - 04.01.2021 гг. Исходя из диаграммы, на это время накладываются два события, связанных с Рождеством и Новым годом - Christmas&New Year Promo и CIS New Year Gift Lottery. Уточним, действительно ли это так:

In [26]:
# маркетинговые события продолжающиеся после начала теста
marketing_events.query('start_dt >= "2020-12-07"')
Out[26]:
name regions start_dt finish_dt
0 Christmas&New Year Promo EU, N.America 2020-12-25 2021-01-03
10 CIS New Year Gift Lottery CIS 2020-12-30 2021-01-07

Так и есть. При этом пользователей из EU касается только одно событие 'Christmas&New Year Promo'.

Довольно странно, что время проведения теста приходится на предновогоднии дни, сезонный всплеск активности клиентов и без проведения маркетинговых мероприятий может исказить результаты теста.

Участники теста

In [27]:
participants
Out[27]:
user_id group ab_test
0 D1ABA3E2887B6A73 A recommender_system_test
1 A7A3664BD6242119 A recommender_system_test
2 DABC14FDDFADD29E A recommender_system_test
3 04988C5DF189632E A recommender_system_test
4 482F14783456D21B B recommender_system_test
... ... ... ...
18263 1D302F8688B91781 B interface_eu_test
18264 3DE51B726983B657 A interface_eu_test
18265 F501F79D332BE86C A interface_eu_test
18266 63FBE257B05F2245 A interface_eu_test
18267 79F9ABFB029CF724 B interface_eu_test

18268 rows × 3 columns

In [28]:
# число уникальных пользователей
participants['user_id'].nunique()
Out[28]:
16666

Число уникальных пользователей теста меньше, чем количество строк в таблице. Это говорит о том, что некоторые пользователи попали сразу в несколько тестов или групп. Выясним, в чем дело:

In [29]:
# уникальные значения в столбце Группа пользователя и их повторяемость
participants['group'].value_counts()
Out[29]:
A    9655
B    8613
Name: group, dtype: int64
In [30]:
# разница в % между группами А и В
round(((1 - participants[participants['group'] == "B"]['group'].count() / participants[participants['group'] == "A"]['group'].count()) * 100), 1)
Out[30]:
10.8

Группы А и В по числу участников различаются на ~ 11 %.

In [31]:
# распределение пользователей между тестами
participants['ab_test'].value_counts()
Out[31]:
interface_eu_test          11567
recommender_system_test     6701
Name: ab_test, dtype: int64

Помимо интересующего нас теста 'recommender_system_test' в данных обнаружен второй тест 'interface_eu_test'. Удостоверимся, что с конкурирующим тестом нет пересечений по пользователям:

In [32]:
# группировка и фильтрация пользователей по присутствию в двух тестах одновременно
len(participants.groupby('user_id').agg({'ab_test': 'nunique'}).reset_index().query('ab_test == 2'))
Out[32]:
1602

1602 пользователя участвуют сразу в двух тестах, отберем пользователей только по тесту 'recommender_system_test':

In [33]:
# фильтрация тестов 
participants = participants.query('ab_test == "recommender_system_test"')
participants['ab_test'].value_counts()
Out[33]:
recommender_system_test    6701
Name: ab_test, dtype: int64

Проверим, среди оставшихся пользователей имеются ли те, кто участвует в двух группах теста:

In [34]:
# пользователи попавшие и в А и В группу
len(participants.groupby('user_id').agg({'group': 'nunique'}).reset_index().query('group == 2'))
Out[34]:
0

Пользователей, участвующих сразу в двух группах теста, нет. Посмотрим, как изменилось распределение пользователей между группами после исключения из данных теста 'interface_eu_test':

In [35]:
# уникальные значения в столбце Группа пользователя и их повторяемость
participants['group'].value_counts()
Out[35]:
A    3824
B    2877
Name: group, dtype: int64
In [36]:
# разница в % между группами А и В
round(((1 - participants[participants['group'] == "B"]['group'].count() / participants[participants['group'] == "A"]['group'].count()) * 100), 1)
Out[36]:
24.8

Пользователи в тестовых группах распределены неравномерно, разница между ними составляет ~ 25 %.

Посчитаем количество новых пользователей - участников теста из EU и их процент от общего числа новых пользователей:

In [37]:
# участники из EU после чистки данных
print('Количество новых уникальных пользователей из EU:', len(list(set(participants['user_id']) & set(new_users['user_id']))))
# % участников из EU после чистки от общего числа участников из EU до чистки
print('% новых уникальных пользователей из EU от общего числа новых пользователей из всех регионов:', round((len(list(set(participants['user_id']) & set(new_users['user_id']))) / 46270)*100, 2))
Количество новых уникальных пользователей из EU: 6351
% новых уникальных пользователей из EU от общего числа новых пользователей из всех регионов: 13.73

Условие по ожидаемому количеству участников теста соблюдено.

А вот по аудитории нет: % новых пользователей из региона EU получился меньше заявленного в ТЗ и составил ~14 %.

Действия новых пользователей в период с 7 декабря 2020 по 4 января 2021 года

In [38]:
events
Out[38]:
user_id event_dt event_name details
0 E1BDDCE0DAFA2679 2020-12-07 20:22:03 purchase 99.99
1 7B6452F081F49504 2020-12-07 09:22:53 purchase 9.99
2 9CD9F34546DF254C 2020-12-07 12:59:29 purchase 4.99
3 96F27A054B191457 2020-12-07 04:02:40 purchase 4.99
4 1FD7660FDF94CA1F 2020-12-07 10:15:09 purchase 4.99
... ... ... ... ...
440312 245E85F65C358E08 2020-12-30 19:35:55 login NaN
440313 9385A108F5A0A7A7 2020-12-30 10:54:15 login NaN
440314 DB650B7559AC6EAC 2020-12-30 10:59:09 login NaN
440315 F80C9BDDEA02E53C 2020-12-30 09:53:39 login NaN
440316 7AEC61159B672CC5 2020-12-30 11:36:13 login NaN

440317 rows × 4 columns

In [39]:
# общее число действий в логе
print('Общее число действий:', events['user_id'].count())

# количество уникальных значений в столбце user_id
print('Количество уникальных пользователей, совершивших действие:', events['user_id'].nunique())

# число действий на уникального пользователя
print('Количество действий на уникального пользователя:', round(events['user_id'].count() / events['user_id'].nunique(), 2))
Общее число действий: 440317
Количество уникальных пользователей, совершивших действие: 58703
Количество действий на уникального пользователя: 7.5

На каждого уникального пользователя приходится ~ 8 действий.

Посмотрим, как число событий распределено во времени:

In [40]:
# минимальное время и дата
display(events['event_dt'].min())

# максимальное время и дата
events['event_dt'].max()
Timestamp('2020-12-07 00:00:33')
Out[40]:
Timestamp('2020-12-30 23:36:33')
In [41]:
# добавление столбца даты регистрации для получения даты без времени 00:00:00
events['purchase_date'] = events['event_dt'].dt.date
# столбчатая диаграмма Распределение событий во времени
sns.set_theme(style="whitegrid")
events.groupby('purchase_date').agg({'event_name': 'count'}).plot(kind = 'bar', figsize = (15, 5))
plt.title('Распределение событий во времени', fontsize=15)
plt.xlabel('Дата')
plt.ylabel('Количество событий')
plt.xticks(rotation = 30)
plt.show()

Удивительно, что последнее действие пользователей датируется 30 декабря 2020 года, что противоречит документации, в которой правая временная граница обозначена, как 04 января 2021 года.

Для дальнейшего проведения исследовательского анализа объединим таблицы 'events', 'participants' и 'new_users' в одну. Предварительно посмотрим на пересечение трех датасетов, это даст нам информацию о числе уникальных пользователей, которое должно совпасть с числом уникальных пользователей после слияния таблиц.

In [42]:
# пересечение трех множеств (количество пользователей, общее для 3-х датасетов)
len(list(set(events['user_id']) & set(participants['user_id']) & set(new_users['user_id'])))
Out[42]:
3481

Получили 3481 уникального пользователя.

In [43]:
# объединение 'events' и 'participants'
events = events.merge(participants, on = 'user_id', how = 'inner')

# объединение 'events' и 'new_users'
events = events.merge(new_users, on = 'user_id', how = 'inner')
events
Out[43]:
user_id event_dt event_name details purchase_date group ab_test first_date region device
0 831887FE7F2D6CBA 2020-12-07 06:50:29 purchase 4.99 2020-12-07 A recommender_system_test 2020-12-07 EU Android
1 831887FE7F2D6CBA 2020-12-09 02:19:17 purchase 99.99 2020-12-09 A recommender_system_test 2020-12-07 EU Android
2 831887FE7F2D6CBA 2020-12-07 06:50:30 product_cart NaN 2020-12-07 A recommender_system_test 2020-12-07 EU Android
3 831887FE7F2D6CBA 2020-12-08 10:52:27 product_cart NaN 2020-12-08 A recommender_system_test 2020-12-07 EU Android
4 831887FE7F2D6CBA 2020-12-09 02:19:17 product_cart NaN 2020-12-09 A recommender_system_test 2020-12-07 EU Android
... ... ... ... ... ... ... ... ... ... ...
23415 1484BBF124DB1B18 2020-12-27 10:53:34 login NaN 2020-12-27 A recommender_system_test 2020-12-21 EU PC
23416 BEF16764A13AEC34 2020-12-21 03:49:49 login NaN 2020-12-21 B recommender_system_test 2020-12-21 EU PC
23417 BEF16764A13AEC34 2020-12-22 18:52:25 login NaN 2020-12-22 B recommender_system_test 2020-12-21 EU PC
23418 BEF16764A13AEC34 2020-12-24 22:11:00 login NaN 2020-12-24 B recommender_system_test 2020-12-21 EU PC
23419 23DDD27AC3FEFA63 2020-12-21 02:51:45 login NaN 2020-12-21 A recommender_system_test 2020-12-21 EU PC

23420 rows × 10 columns

In [44]:
# количество полных дубликатов
events.duplicated().sum()
Out[44]:
0
In [45]:
# уникальные пользователи, состоящие в нескольких группах одновременно 
events.groupby('user_id')['group'].agg('nunique').reset_index().query('group > 1')
Out[45]:
user_id group

Объединенная таблица содержит 23 420 строк и 10 столбцов. Полные дубликаты не обнаружены. Пользователи состоящие сразу в нескольких группах отсутствуют.

In [46]:
# общее число действий в итоговой таблице
print('Общее число действий:', events['user_id'].count())

# количество уникальных значений в столбце user_id
print('Количество уникальных пользователей, совершивших действие:', events['user_id'].nunique())

# число действий на уникального пользователя
print('Количество действий на уникального пользователя:', round(events['user_id'].count() / events['user_id'].nunique(), 2))
Общее число действий: 23420
Количество уникальных пользователей, совершивших действие: 3481
Количество действий на уникального пользователя: 6.73

Как и было подсчитано выше, в итоговой таблице обнаружено 3481 уникальных пользователя, совершивших действие. До объединения таблиц число уникальных пользователей было 6351. Получается, что 2870 пользователей действий не совершали. Таким образом, условие по ожидаемому количеству участников теста все таки не соблюдается.

При этом, кого считать участником теста: того, кто зарегистрировался или того, кто прошел авторизацию? Остается вопросом.

На каждого уникального пользователя приходится ~ 7 действий. Посмотрим, одинаково ли распределены события в выборках:

In [47]:
# группировка уникальных пользователей по группам
users_by_group = events.groupby('group')['user_id'].nunique().reset_index()
users_by_group.columns = ['group', 'users_nunique']

# группировка всех пользователей по группам
users_by_group_count = events.groupby('group')['user_id'].count().reset_index()
users_by_group_count.columns = ['group', 'users_count']

# объединение двух таблиц
event_count_group = users_by_group.merge(users_by_group_count, on = 'group', how = 'inner')
event_count_group['event_count'] = round(event_count_group['users_count'] / event_count_group['users_nunique'], 2)
display(event_count_group)

# барплот 'Распределение количества действий на пользователя по группам'
sns.set_theme(style="whitegrid")
plt.figure(figsize=(15,5))
sns.barplot(data=event_count_group, x='group', y = 'event_count',)
plt.title('Распределение событий на уникального пользователя в группах', fontsize=15)
plt.xlabel('Группа')
plt.ylabel('Количество действий')
plt.xticks(rotation = 0)
plt.show()
group users_nunique users_count event_count
0 A 2604 18309 7.03
1 B 877 5111 5.83

В контрольной группе А на одного уникального пользователя приходится ~ 7 действий, в новой группе В меньше, ~ 6 действий.

Исследуем, как число событий в выборках распределено по дням:

In [48]:
# столбчатая диаграмма Распределение событий во времени по группам
sns.set_theme(style="whitegrid")
ax = events.query('group == "A"').groupby('purchase_date').agg({'event_name': 'count'}).plot(kind = 'bar', figsize = (15, 5))
events.query('group == "B"').groupby('purchase_date').agg({'event_name': 'count'}).plot(kind = 'bar', ax=ax, color='red', alpha=0.6)
plt.title('Распределение событий во времени по группам', fontsize=15)
plt.xlabel('Дата')
plt.ylabel('Количество событий')
plt.xticks(rotation = 30)
plt.legend(['A', 'B'])
plt.show()

Столбчатая диаграмма наглядно отражает сильное различие в числе событий между двумя группами: в новой группе В существенно меньше событий.

В контрольной группе А с начала теста и по 13.12.2020 года активность не наблюдается, после чего происходит резкий скачок, который можно объяснить подготовкой пользователей к праздникам. Пик событий приходится на 21.12.2020, затем виден плавный спад.

Если про контрольную группу еще можно сказать, что распределение данных похоже на нормальное, то в новой группе В, события распределяются очень странным образом. Количество событий то нарастает, то падает. Днями, когда происходит наибольшее число событий, можно считать: 21 декабря, 16 декабря , 07 декабря и 09 декабря. После 21 декабря наблюдается резкий спад.

Возможно, что во время проведения теста произошли технические сбои или вероятно, нам не догрузили данные.

Посмотрим, какие события встречаются в логах:

Встречаются следующие типы событий:

1) login - авторизация;

2) product_page - просмотр карточек товаров;

3) purchase - покупка;

4) product_cart - просмотр корзины.

Посчитаем, сколько пользователей совершали каждое из этих событий:

In [49]:
# количество пользователей совершивших каждое из этих событий
funnel = events.groupby('event_name')['user_id'].nunique().reset_index().rename(columns = {'user_id': 'user_count'}).sort_values(by = 'user_count', ascending = False)

# процент пользователей, хоть раз совершивших событие
funnel['percent'] = round((funnel['user_count'] / events['user_id'].nunique() * 100), 1)

funnel
Out[49]:
event_name user_count percent
0 login 3481 100.0
2 product_page 2178 62.6
3 purchase 1082 31.1
1 product_cart 1026 29.5

Построим воронку событий:

In [50]:
# воронка событий plotly
fig = go.Figure(
    go.Funnel(
        y = funnel['event_name'],
        x = funnel['user_count'],
        textinfo = "value+percent previous+percent initial",
    )
)
fig.update_layout(
    title={
        'text': 'Воронка событий',
        'y':0.9,
        'x':0.5,
        'xanchor': 'center',
        'yanchor': 'top'
    }
)
fig.show()

По воронке событий наглядно видно, какая доля пользователей проходит на следующий шаг воронки (от числа пользователей на предыдущем):

  • Количество уникальных пользователей в датасете равно числу 'Авторизовавшихся' (3481);

  • 'Просмотрели карточки товаров' 62.6 % пользователей от 'Авторизовавшихся';

  • 'Совершили покупку' 49.7% от 'Просмотревших карточку' и 31.1 % от 'Авторизовавшихся';

  • 'Просмотрели корзину' 94.8 % от 'Совершивших покупку' и 29.5 % от 'Авторизовавшихся'.

Вызывает интерес тот факт, что 56 покупок совершены в обход корзины. Это может быть связано, как с технологической ошибкой, так и с возможностью купить товар в один клик. По этой причине шаг 'просмотр корзины' расположен в самом низу.

Таким образом, больше всего пользователей теряется на шаге 'Авторизация' - около 37 %.

От первого события до оплаты доходит примерно 31 % пользователей.

Построим воронку событий по группам и посмотрим, как меняется конверсия в выборках на разных этапах:

In [51]:
# воронка событий в разбивке по группам
group_funnel = events.pivot_table(index = 'event_name', columns = 'group', values = 'user_id', aggfunc = 'nunique').reset_index()
group_funnel.columns = ['event_name', 'A', 'B',]
group_funnel = group_funnel.sort_values(by = 'A', ascending=False)
group_funnel
Out[51]:
event_name A B
0 login 2604 877
2 product_page 1685 493
3 purchase 833 249
1 product_cart 782 244
In [52]:
# воронка событий plotly
fig = go.Figure()
fig.add_trace(go.Funnel(
        name = 'A',
        y = group_funnel['event_name'],
        x = group_funnel['A'],
        textinfo = "value+percent previous+percent initial"))

fig.add_trace(go.Funnel(
        name = 'B',
        y = group_funnel['event_name'],
        x = group_funnel['B'],
        textinfo = "value+percent previous+percent initial",
        textposition = "inside"))


fig.update_layout(
    title={
        'text': 'Воронка событий по группам',
        'y':0.9,
        'x':0.5,
        'xanchor': 'center',
        'yanchor': 'top',       
    }
)

fig.show()

Самое популярное событие - просмотр карточек товаров (product_page). В группе A его совершили 1685 пользователей (65 %), в группе B: 493 пользователя (56 %).

In [53]:
# улучшение метрик за все время проведения теста
group_funnel['%_of_initial_A'] = round(group_funnel['A'] / group_funnel.loc[0,'A'], 3)
group_funnel['%_of_initial_B'] = round(group_funnel['B'] / group_funnel.loc[0,'B'], 3)
group_funnel['metric_change_%'] = round((group_funnel['%_of_initial_B'] / group_funnel['%_of_initial_A'] - 1) * 100, 2)
group_funnel
Out[53]:
event_name A B %_of_initial_A %_of_initial_B metric_change_%
0 login 2604 877 1.000 1.000 0.00
2 product_page 1685 493 0.647 0.562 -13.14
3 purchase 833 249 0.320 0.284 -11.25
1 product_cart 782 244 0.300 0.278 -7.33

За все время проведения теста (07.12.2020 - 30.12.2020) получен следующий эффект:

  • конверсия в просмотр карточек товаров — событие product_page — ухудшение метрики на 13 %,

  • просмотры корзины — product_cart — ухудшение метрики на 7 %,

  • покупки — purchase — ухудшение метрики на 11 %.

Если такой отрицательный результат получен за 24 дня, ни о каком улучшении каждой метрики не менее, чем на 10 % за 14 дней, не может быть и речи.

Прежде чем приступать к А/В-тестированию следовало учесть, корректность деления трафика между группами. Например, недопустимо, чтобы пользователи группы A — были посетителями мобильной версии сайта, а пользователи группы B — десктопной. Проверим, как обстоят дела с распределением пользователей по устройствам в группах:

In [54]:
# группировка уникальных пользователей по устройствам и группам
group_by_device = events.groupby(['device', 'group'])['user_id'].nunique().reset_index()

# объединение двух таблиц по столбцу группа и переименование столбцов
group_by_device = group_by_device.merge(users_by_group, on = 'group', how = 'inner')
group_by_device.columns = ['device', 'group', 'users_in_group', 'total_in_group']

# добавление столбца доля пользователей, использующих устройство от общего в группе
group_by_device['rate'] = round(group_by_device['users_in_group'] / group_by_device['total_in_group'], 3)
display(group_by_device)

# барплот 'Распределение уникальных пользователей по устройствам с учетом группы'
sns.set_theme(style="whitegrid")
plt.figure(figsize=(15,5))
sns.barplot(data=group_by_device, x="device", y = 'rate', hue="group",)
plt.title('Распределение уникальных пользователей по устройствам в группах', fontsize=15)
plt.xlabel('Устройства')
plt.ylabel('Доля пользователей')
plt.xticks(rotation = 0)
plt.show()
device group users_in_group total_in_group rate
0 Android A 1139 2604 0.437
1 Mac A 255 2604 0.098
2 PC A 689 2604 0.265
3 iPhone A 521 2604 0.200
4 Android B 405 877 0.462
5 Mac B 74 877 0.084
6 PC B 212 877 0.242
7 iPhone B 186 877 0.212

Между долями пользователей устройств имеются несущественные различия по группам.

Промежуточные выводы¶

1) Данные не соответствуют требованиям технического задания:

  • помимо интересующего нас теста 'recommender_system_test' в данных обнаружен второй тест 'interface_eu_test', с которым имеются пересечения. Пользователей, участвующих сразу в двух группах теста, нет;

  • дата остановки набора новых пользователей отличается - 2020-12-23. С датой начала набора все в порядке;

  • время проведения теста совпадает с событием 'Christmas&New Year Promo', это может повлиять на достоверность результата теста;

  • пользователи в контрольной группе А и новой группе В (новая платежная воронка) распределены неравномерно, и после объединения данных в одну таблицу разница между ними составляет ~ 34 %;

  • условие по ожидаемому количеству участников теста не соблюдено (3481). % новых пользователей из региона EU также получился меньше заявленного и составил ~14 %;

  • последнее действие пользователей датируется 30 декабря 2020 года, что противоречит документации, в которой датой остановки теста указано 04 января 2021 года;

  • время проведения теста выбрано неудачно, т.к. приходится на предновогоднии дни, сезонный всплеск активности клиентов и без проведения маркетинговых мероприятий может исказить результаты теста.

2) Из результатов исследовательского анализа следует:

  • в контрольной группе А на одного уникального пользователя приходится ~ 7 действий, в новой группе В меньше, ~ 6 действий;

  • 56 покупок совершены в обход корзины. Это может быть связано, как с технологической ошибкой, так и с возможностью купить товар в один клик. От первого события до оплаты доходит примерно 31 % пользователей;

  • ожидаемый эффект — улучшение каждой метрики не менее, чем на 10 %, не достигнут за все время проведения теста (07.12.2020 - 30.12.2020). Нет смысла проверять результат за 14 дней. Получены следующие результаты:

    • конверсия в просмотр карточек товаров — ухудшение метрики на 13 %,

    • просмотры корзины — ухудшение метрики на 7 %,

    • покупки — ухудшение метрики на 11 %.

  • при регистрации клиенты использовали: Android, PC, iPhone и Mac. Между долями пользователей устройств имеются несущественные различия по группам.

Полученные результаты позволяют сделать вывод о некорректности проведенного теста.

Оценка результатов А/В-тестирования¶

Проверим статистическую разницу долей z-критерием.

Сформулируем нулевую и альтернативную гипотезы:

Н0 (Нулевая гипотеза): между выборками A и В нет отличий в доле пользователей, совершивших событие Х;

Н1 (Альтернативная гипотеза): между выборками A и В есть отличия в доле пользователей, совершивших событие Х.

Примем критический уровень статистической знаимости (alpha) равным 0.05.

In [55]:
# воронка событий по группам
ab_funnel = events.pivot_table(index = 'event_name', columns = 'group', values = 'user_id', aggfunc = 'nunique').sort_values(by = 'B', ascending = False)
ab_funnel = ab_funnel.query('event_name != "login"')
ab_funnel
Out[55]:
group A B
event_name
product_page 1685 493
purchase 833 249
product_cart 782 244
In [56]:
# количество пользователей в каждой группе
trials = users_by_group
trials = trials.set_index(trials.columns[0])
trials
Out[56]:
users_nunique
group
A 2604
B 877
In [57]:
# исследование отличий для групп A и B по событиям
def z_test (exp1, exp2, event, alpha):
    successes1 = ab_funnel.loc[event, exp1]
    successes2 = ab_funnel.loc[event, exp2]
    trials1 = trials.loc[exp1, 'users_nunique']
    trials2 = trials.loc[exp2, 'users_nunique']
    
    # пропорция успехов в обеих группах:
    p1 = successes1/trials1
    p2 = successes2/trials2
    
    # пропорция успехов в комбинированном датасете:
    p_combined = (successes1 + successes2) / (trials1 + trials2)
    
    # разница пропорций в датасетах
    difference = p1 - p2
    
    # считаем статистику в ст.отклонениях стандартного нормального распределения
    z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials1 + 1/trials2))
    
    # задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
    distr = st.norm(0, 1)  
    p_value = (1 - distr.cdf(abs(z_value))) * 2
    print('Для групп {} и {} по событию {} p-значение: {p_value:.2f}'.format(exp1, exp2, event, p_value=p_value))
    if (p_value < alpha):
        print("Отвергаем нулевую гипотезу: между долями есть значимая разница")
    else:
        print("Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными")
In [58]:
# вызов функции z-test для A и B групп
for event in ab_funnel.index:
    z_test("A", "B", event, 0.05)
    print()
Для групп A и B по событию product_page p-значение: 0.00
Отвергаем нулевую гипотезу: между долями есть значимая разница

Для групп A и B по событию purchase p-значение: 0.05
Отвергаем нулевую гипотезу: между долями есть значимая разница

Для групп A и B по событию product_cart p-значение: 0.21
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

По событиям product_page и purchase отвергаем нулевую гипотезу, между долями есть статистическая значимость.

По событию product_cart не получилось отвергнуть нулевую гипотезу, статистической разницы между долями нет.

В исследовании проведено 3 сравнения, уровень значимости был принят равным 0.05. Чтобы снизить вероятность ложнопозитивного результата при множественном тестировании гипотез, применяют разные методы корректировки уровня значимости для уменьшения FWER. Из-за простоты решения применим поправку Бонфферони:

In [59]:
print('Поправка Бонфферони для 3 сравнений с уровнем значимости 0.05:', round(0.05/3, 6))
Поправка Бонфферони для 3 сравнений с уровнем значимости 0.05: 0.016667
In [60]:
# вызов функции z-test для A и B групп
for event in ab_funnel.index:
    z_test("A", "B", event, 0.016667)
    print()
Для групп A и B по событию product_page p-значение: 0.00
Отвергаем нулевую гипотезу: между долями есть значимая разница

Для групп A и B по событию purchase p-значение: 0.05
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

Для групп A и B по событию product_cart p-значение: 0.21
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

От изменения уровня значимости p-value не зависит, поэтому p-значения остались такими же, а вот результаты сравнения получены другие:

после применения поправки Бонфферони по событию purchase не получилось отвергнуть нулевую гипотезу, между долями нет статистической разницы.

Промежуточные выводы¶

Проверка статистической разницы долей z-критерием показала, что:

  • между долями контрольной группы А и новой группы В по событию просмотр карточек товаров есть статистическая значимость;

  • по событиям покупка и просмотр корзины статистической разницы между долями групп А и В нет. Разница в конверсии по покупкам (-11.25 %) и просмотрам корзины (-7.33 %), расчитанная на этапе EDA, могла быть получена случайно.

Заключение о корректности проведения теста¶

На основании полученных результатов можно заключить, что тест проведен некорректно:

  • пользователи в контрольной группе А и новой группе В (новая платежная воронка) распределены неравномерно, разница между ними составляет ~ 34 %;

  • в данных присутсвует второй тест 'interface_eu_test', с которым имеются пересечения;

  • время проведения теста совпадает с событием 'Christmas&New Year Promo';

  • время проведения теста (2020-12-07 - 2020-12-30) отличается от заявленного и накладывается на Новогоднее и Рождественское время в EU;

  • условие по ожидаемому количеству участников теста не соблюдено (3481). % новых пользователей из региона EU также получился меньше заявленного и составил ~14 %.

За фактическое время проведения теста улучшение каждой метрики не менее, чем на 10 %, не достигнуто, наоборот, получен отрицательный эффект.

Проверка статистической разницы долей z-критерием показала, что:

  • между долями контрольной группы А и новой группы В по событию просмотр карточек товаров есть статистическая значимость;

  • по событиям покупка и просмотр корзины статистической разницы между долями групп А и В нет. Разница в конверсии по покупкам (-11.25 %) и просмотрам корзины (-7.33 %), могла быть получена случайно.

В качестве рекомендаций можно предложить:

  • следить за корректным делением трафика теста;

  • сопоставлять даты проведения теста с календарем маркетинговых событий;

  • брать в расчет всплески активности конкретно своей аудитории (сезонность, праздники, выходные дни и т.д.).